summaryrefslogtreecommitdiff
path: root/apps/web/app/shared/[token]
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-08 07:07:59 -0800
committerFuwn <[email protected]>2026-02-08 07:07:59 -0800
commita33dbfa6a1cb1d34ce9a5286efdb25818ff7b6c1 (patch)
tree7d44bdcb94cc1b69fbc201a4757f27f3751c5adb /apps/web/app/shared/[token]
parentchore: gate Vercel analytics and speed insights to production only (diff)
downloadasa.news-a33dbfa6a1cb1d34ce9a5286efdb25818ff7b6c1.tar.xz
asa.news-a33dbfa6a1cb1d34ce9a5286efdb25818ff7b6c1.zip
feat: share with highlighted excerpt and fix auth redirect URLs
Add "share" button to text selection toolbar so users can share an entry with a highlighted passage visible to visitors. The public share page renders the highlight and scrolls to it on load. Also fix magic link and password reset redirects to use NEXT_PUBLIC_APP_URL instead of window.location.origin so emails link to the production domain.
Diffstat (limited to 'apps/web/app/shared/[token]')
-rw-r--r--apps/web/app/shared/[token]/page.tsx51
-rw-r--r--apps/web/app/shared/[token]/shared-entry-content.tsx57
2 files changed, 92 insertions, 16 deletions
diff --git a/apps/web/app/shared/[token]/page.tsx b/apps/web/app/shared/[token]/page.tsx
index 222c1c8..7c7a463 100644
--- a/apps/web/app/shared/[token]/page.tsx
+++ b/apps/web/app/shared/[token]/page.tsx
@@ -1,14 +1,28 @@
import type { Metadata } from "next"
import { createSupabaseAdminClient } from "@/lib/supabase/admin"
import { sanitizeEntryContent } from "@/lib/sanitize"
+import { SharedEntryContent } from "./shared-entry-content"
interface SharedPageProperties {
params: Promise<{ token: string }>
}
+interface SharedHighlightData {
+ highlightedText: string
+ textOffset: number
+ textLength: number
+ textPrefix: string
+ textSuffix: string
+}
+
interface SharedEntryRow {
entry_id: string
expires_at: string | null
+ highlighted_text: string | null
+ highlight_text_offset: number | null
+ highlight_text_length: number | null
+ highlight_text_prefix: string | null
+ highlight_text_suffix: string | null
entries: {
id: string
title: string | null
@@ -30,7 +44,7 @@ async function fetchSharedEntry(token: string) {
const { data, error } = await adminClient
.from("shared_entries")
.select(
- "entry_id, expires_at, entries!inner(id, title, url, author, summary, content_html, published_at, enclosure_url, feeds!inner(title))"
+ "entry_id, expires_at, highlighted_text, highlight_text_offset, highlight_text_length, highlight_text_prefix, highlight_text_suffix, entries!inner(id, title, url, author, summary, content_html, published_at, enclosure_url, feeds!inner(title))"
)
.eq("share_token", token)
.maybeSingle()
@@ -43,7 +57,22 @@ async function fetchSharedEntry(token: string) {
return { expired: true as const }
}
- return { expired: false as const, entry: row.entries }
+ let highlightData: SharedHighlightData | null = null
+ if (
+ row.highlighted_text &&
+ row.highlight_text_offset !== null &&
+ row.highlight_text_length !== null
+ ) {
+ highlightData = {
+ highlightedText: row.highlighted_text,
+ textOffset: row.highlight_text_offset,
+ textLength: row.highlight_text_length,
+ textPrefix: row.highlight_text_prefix ?? "",
+ textSuffix: row.highlight_text_suffix ?? "",
+ }
+ }
+
+ return { expired: false as const, entry: row.entries, highlightData }
}
export async function generateMetadata({
@@ -67,18 +96,6 @@ export async function generateMetadata({
}
}
-function SanitisedContent({ htmlContent }: { htmlContent: string }) {
- // Content is sanitised via sanitize-html before rendering
- const sanitisedHtml = sanitizeEntryContent(htmlContent)
- return (
- <div
- className="prose-reader text-text-secondary"
- // eslint-disable-next-line react/no-danger -- content sanitised by sanitize-html
- dangerouslySetInnerHTML={{ __html: sanitisedHtml }}
- />
- )
-}
-
export default async function SharedPage({ params }: SharedPageProperties) {
const { token } = await params
const result = await fetchSharedEntry(token)
@@ -106,6 +123,7 @@ export default async function SharedPage({ params }: SharedPageProperties) {
}
const entry = result.entry
+ const sanitisedHtml = sanitizeEntryContent(entry.content_html || entry.summary || "")
const formattedDate = entry.published_at
? new Date(entry.published_at).toLocaleDateString("en-GB", {
day: "numeric",
@@ -133,8 +151,9 @@ export default async function SharedPage({ params }: SharedPageProperties) {
/>
</div>
)}
- <SanitisedContent
- htmlContent={entry.content_html || entry.summary || ""}
+ <SharedEntryContent
+ sanitisedHtml={sanitisedHtml}
+ highlightData={result.highlightData}
/>
</article>
<footer className="mt-12 border-t border-border pt-4 text-text-dim">
diff --git a/apps/web/app/shared/[token]/shared-entry-content.tsx b/apps/web/app/shared/[token]/shared-entry-content.tsx
new file mode 100644
index 0000000..aaa9892
--- /dev/null
+++ b/apps/web/app/shared/[token]/shared-entry-content.tsx
@@ -0,0 +1,57 @@
+"use client"
+
+import { useEffect, useRef } from "react"
+import {
+ deserializeHighlightRange,
+ applyHighlightToRange,
+} from "@/lib/highlight-positioning"
+
+interface SharedHighlightData {
+ highlightedText: string
+ textOffset: number
+ textLength: number
+ textPrefix: string
+ textSuffix: string
+}
+
+interface SharedEntryContentProperties {
+ sanitisedHtml: string
+ highlightData: SharedHighlightData | null
+}
+
+export function SharedEntryContent({
+ sanitisedHtml,
+ highlightData,
+}: SharedEntryContentProperties) {
+ const containerReference = useRef<HTMLDivElement>(null)
+
+ useEffect(() => {
+ const container = containerReference.current
+ if (!container) return
+
+ container.innerHTML = sanitisedHtml
+
+ if (!highlightData) return
+
+ const highlightRange = deserializeHighlightRange(container, highlightData)
+ if (!highlightRange) return
+
+ applyHighlightToRange(highlightRange, "shared-highlight", "yellow", false)
+
+ requestAnimationFrame(() => {
+ const markElement = container.querySelector(
+ 'mark[data-highlight-identifier="shared-highlight"]'
+ )
+ if (markElement) {
+ markElement.scrollIntoView({ behavior: "smooth", block: "center" })
+ }
+ })
+ }, [sanitisedHtml, highlightData])
+
+ return (
+ <div
+ ref={containerReference}
+ className="prose-reader text-text-secondary"
+ />
+ )
+}